import EventEmitter from "events";
import { Logging } from "../logging/logging";
import { NullLogging } from "../logging/logging";
import { withTimeout, TimeoutError } from "./async-util";
import { DAPWrapper } from "./dap-wrapper";
import { PartialFlashing } from "./partial-flashing";
import {
  ConnectionStatus,
  ConnectOptions,
  DeviceConnection,
  EVENT_END_USB_SELECT,
  EVENT_FLASH,
  EVENT_SERIAL_DATA,
  EVENT_SERIAL_ERROR,
  EVENT_SERIAL_RESET,
  EVENT_START_USB_SELECT,
  EVENT_STATUS,
  FlashDataSource,
  HexGenerationError,
  MicrobitWebUSBConnectionOptions,
  WebUSBError,
} from "./device";
import { generateMicrobitFS } from "../utils";
import { WebUSB } from "usb";

// Temporary workaround for ChromeOS 105 bug.
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1363712&q=usb&can=2
export const isChromeOS105 = (): boolean => {
  const userAgent = globalThis.window?.navigator?.userAgent || "";
  return /CrOS/.test(userAgent) && /Chrome\/105\b/.test(userAgent);
};

/**
 * A WebUSB connection to a micro:bit device.
 */
export class MicrobitWebUSBConnection
  extends EventEmitter
  implements DeviceConnection {
  status: ConnectionStatus =
    globalThis.window?.navigator?.usb && !isChromeOS105()
      ? ConnectionStatus.NO_AUTHORIZED_DEVICE
      : ConnectionStatus.NOT_SUPPORTED;

  /**
   * The USB device we last connected to.
   * Cleared if it is disconnected.
   */
  private device: USBDevice | undefined;
  /**
   * The connection to the device.
   */
  private connection: DAPWrapper | undefined;

  /**
   * DAPLink gives us a promise that lasts as long as we're serial reading.
   * When stopping serial we await it to be sure we're done.
   */
  private serialReadInProgress: Promise<void> | undefined;

  private serialListener = (data: string) => {
    this.emit(EVENT_SERIAL_DATA, data);
  };

  private flashing: boolean = false;
  private disconnectAfterFlash: boolean = false;
  private visibilityReconnect: boolean = false;
  private visibilityChangeListener = () => {
    if (document.visibilityState === "visible") {
      if (
        this.visibilityReconnect &&
        this.status !== ConnectionStatus.CONNECTED
      ) {
        this.disconnectAfterFlash = false;
        this.visibilityReconnect = false;
        if (!this.flashing) {
          this.log("Reconnecting visible tab");
          this.connect();
        }
      }
    } else {
      if (!this.unloading && this.status === ConnectionStatus.CONNECTED) {
        if (!this.flashing) {
          this.log("Disconnecting hidden tab");
          this.disconnect().then(() => {
            this.visibilityReconnect = true;
          });
        } else {
          this.log("Scheduling disconnect of hidden tab for after flash");
          this.disconnectAfterFlash = true;
        }
      }
    }
  };

  private unloading = false;

  private beforeUnloadListener = () => {
    // If serial is in progress when the page unloads with V1 DAPLink 0254 or V2 0255
    // then it'll fail to reconnect with mismatched command/response errors.
    // Try hard to disconnect as a workaround.
    // https://github.com/microbit-foundation/python-editor-v3/issues/89
    this.unloading = true;
    this.stopSerialInternal();
    // The user might stay on the page if they have unsaved changes and there's another beforeunload listener.
    window.addEventListener(
      "focus",
      () => {
        const assumePageIsStayingOpenDelay = 1000;
        setTimeout(() => {
          if (this.status === ConnectionStatus.CONNECTED) {
            this.unloading = false;
            this.startSerialInternal();
          }
        }, assumePageIsStayingOpenDelay);
      },
      { once: true }
    );
  };

  private logging: Logging;

  constructor(
    options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() }
  ) {
    super();
    this.logging = options.logging;
  }

  private log(v: any) {
    this.logging.log(v);
  }

  async initialize(): Promise<void> { }

  dispose() {
    this.removeAllListeners();
  }

  async connect(options: ConnectOptions = {}): Promise<ConnectionStatus> {
    return this.withEnrichedErrors(async () => {
      await this.connectInternal(options);
      return this.status;
    });
  }

  async flash(
    dataSource: FlashDataSource,
    options: {
      /**
       * True to use a partial flash where possible, false to force a full flash.
       */
      partial: boolean;
      /**
       * A progress callback. Called with undefined when the process is complete or has failed.
       */
      progress: (percentage: number | undefined) => void;
    }
  ): Promise<void> {
    this.flashing = true;
    try {
      const startTime = new Date().getTime();
      await this.withEnrichedErrors(() =>
        this.flashInternal(dataSource, options)
      );
      this.emit(EVENT_FLASH);

      const flashTime = new Date().getTime() - startTime;
      this.logging.event({
        type: "WebUSB-time",
        detail: {
          flashTime,
        },
      });
      this.logging.log("Flash complete");
    } finally {
      this.flashing = false;
    }
  }

  private async flashInternal(
    dataSource: FlashDataSource,
    options: {
      partial: boolean;
      progress: (percentage: number | undefined, partial: boolean) => void;
    }
  ): Promise<void> {
    this.log("Stopping serial before flash");
    await this.stopSerialInternal();
    this.log("Reconnecting before flash");
    await this.connectInternal({
      serial: false,
    });
    if (!this.connection) {
      throw new Error("Must be connected now");
    }

    const partial = options.partial;
    const progress = options.progress || (() => { });

    const boardId = this.connection.boardSerialInfo.id;
    const flashing = new PartialFlashing(this.connection, this.logging);
    let wasPartial: boolean = false;
    try {
      if (partial) {
        wasPartial = await flashing.flashAsync(boardId, dataSource, progress);
      } else {
        await flashing.fullFlashAsync(boardId, dataSource, progress);
      }
    } finally {
      progress(undefined, wasPartial);
    }
  }

  private async startSerialInternal() {
    if (!this.connection) {
      // As connecting then starting serial are async we could disconnect between them,
      // so handle this gracefully.
      return;
    }
    if (this.serialReadInProgress) {
      await this.stopSerialInternal();
    }
    // This is async but won't return until we stop serial so we error handle with an event.
    this.serialReadInProgress = this.connection
      .startSerial(this.serialListener)
      .then(() => this.log("Finished listening for serial data"))
      .catch((e) => {
        this.emit(EVENT_SERIAL_ERROR, e);
      });
  }

  private async stopSerialInternal() {
    if (this.connection && this.serialReadInProgress) {
      this.connection.stopSerial(this.serialListener);
      // await withTimeout(this.serialReadInProgress, 1000).catch(() => undefined);
      this.serialReadInProgress = undefined;
      this.emit(EVENT_SERIAL_RESET, {});
    }
  }

  async disconnect(): Promise<void> {
    try {
      if (this.connection) {
        await this.stopSerialInternal();
        await this.connection.disconnectAsync();
      }
    } catch (e) {
      this.log("Error during disconnection:\r\n" + e);
      this.logging.event({
        type: "WebUSB-error",
        message: "error-disconnecting",
      });
    } finally {
      this.connection = undefined;
      this.setStatus(ConnectionStatus.NOT_CONNECTED);

      this.logging.log("Disconnection complete");
      this.logging.event({
        type: "WebUSB-info",
        message: "disconnected",
      });
    }
  }

  private setStatus(newStatus: ConnectionStatus) {
    this.status = newStatus;
    this.visibilityReconnect = false;
    this.log("Device status " + newStatus);
    this.emit(EVENT_STATUS, this.status);
  }

  private async withEnrichedErrors<T>(f: () => Promise<T>): Promise<T> {
    try {
      return await f();
    } catch (e: any) {
      if (e instanceof HexGenerationError) {
        throw e;
      }

      // Log error to console for feedback
      this.log("An error occurred whilst attempting to use WebUSB.");
      this.log(
        "Details of the error can be found below, and may be useful when trying to replicate and debug the error."
      );
      this.log(e);

      // Disconnect from the microbit.
      // Any new connection reallocates all the internals.
      // Use the top-level API so any listeners reflect that we're disconnected.
      await this.disconnect();

      const enriched = enrichedError(e);
      // Sanitise error message, replace all special chars with '-', if last char is '-' remove it
      const errorMessage = e.message
        ? e.message.replace(/\W+/g, "-").replace(/\W$/, "").toLowerCase()
        : "";

      this.logging.event({
        type: "WebUSB-error",
        message: e.code + "/" + errorMessage,
      });
      throw enriched;
    }
  }

  serialWrite(data: string): Promise<void> {
    return this.withEnrichedErrors(async () => {
      if (this.connection) {
        // Using WebUSB/DAPJs we're limited to 64 byte packet size with a two byte header.
        // https://github.com/microbit-foundation/python-editor-v3/issues/215
        const maxSerialWrite = 62;
        let start = 0;
        while (start < data.length) {
          const end = Math.min(start + maxSerialWrite, data.length);
          const chunkData = data.slice(start, end);
          await this.connection.daplink.serialWrite(chunkData);
          start = end;
        }
      }
    });
  }

  public handleDisconnect = (event: USBConnectionEvent) => {
    if (event.device === this.device) {
      this.connection = undefined;
      this.device = undefined;
      this.setStatus(ConnectionStatus.NO_AUTHORIZED_DEVICE);
    }
  };

  async clearDevice(): Promise<void> {
    await this.disconnect();
    this.device = undefined;
    this.setStatus(ConnectionStatus.NO_AUTHORIZED_DEVICE);
  }

  private async connectInternal(options: ConnectOptions): Promise<void> {
    if (!this.connection) {
      const device = await this.chooseDevice();
      this.connection = new DAPWrapper(device, this.logging);
    }
    await withTimeout(this.connection.reconnectAsync(), 10_000);
    if (options.serial === undefined || options.serial) {
      this.startSerialInternal();
    }
    this.setStatus(ConnectionStatus.CONNECTED);
  }

  private async chooseDevice(): Promise<USBDevice> {
    if (this.device) {
      return this.device;
    }
    this.emit(EVENT_START_USB_SELECT);
    this.device = await this.getOneAvailableDevice();
    this.emit(EVENT_END_USB_SELECT);
    return this.device;
  }

  public generateFS = (files: { name: string; content: string }[]) => {
    return generateMicrobitFS(this.logging, files);
  };

  // NOTE: should init getOneAvailableDevice
  public getOneAvailableDevice = async (): Promise<USBDevice> => {
    const connection = new WebUSB({
      allowAllDevices: true,
    });
    const device = await connection.requestDevice({
      filters: [{ vendorId: 0x0d28, productId: 0x0204 }],
    });
    this.logging.log(`Device Found, ${device.serialNumber}`);
    if(!device) {
      throw new Error("No Device Found"); 
    }
    return device;
  };
}

const genericErrorSuggestingReconnect = (e: any) =>
  new WebUSBError({
    code: "reconnect-microbit",
    message: e.message,
  });

// tslint:disable-next-line: no-any
const enrichedError = (err: any): WebUSBError => {
  if (err instanceof WebUSBError) {
    return err;
  }
  if (err instanceof TimeoutError) {
    return new WebUSBError({
      code: "timeout-error",
      message: err.message,
    });
  }

  switch (typeof err) {
    case "object":
      // We might get Error objects as Promise rejection arguments
      if (!err.message && err.promise && err.reason) {
        err = err.reason;
      }
      // This comes from DAPjs's WebUSB open.
      if (err.message === "No valid interfaces found.") {
        return new WebUSBError({
          code: "update-req",
          message: err.message,
        });
      } else if (err.message === "No device selected.") {
        return new WebUSBError({
          code: "no-device-selected",
          message: err.message,
        });
      } else if (err.message === "Unable to claim interface.") {
        return new WebUSBError({
          code: "clear-connect",
          message: err.message,
        });
      } else if (err.name === "device-disconnected") {
        return new WebUSBError({
          code: "device-disconnected",
          message: err.message,
        });
      } else {
        // Unhandled error. User will need to reconnect their micro:bit
        return genericErrorSuggestingReconnect(err);
      }
    case "string": {
      // Caught a string. Example case: "Flash error" from DAPjs
      return genericErrorSuggestingReconnect(err);
    }
    default: {
      return genericErrorSuggestingReconnect(err);
    }
  }
};
